</> 技術筆記Tech Notes

【我如何用 Vue 3 + Canvas 打造一個功能豐富的甘特圖】專案開發全紀錄

今天想跟大家分享我最近做的一個專案:一個用 Vue 3、Pinia 和 HTML5 Canvas 打造的甘特圖。甘特圖在專案管理中非常實用,但要從零開發一個互動流暢、功能又完整的版本,確實會遇到不少坑。

在這篇文章中,我會帶大家走過一遍我的開發過程,從技術選型、架構設計,到核心功能的實作細節,分享我的一些思考和心得。希望無論你是想學習 Vue 3 的實戰應用,還是對 Canvas 渲染技術感興趣,都能從中得到一些啟發。

這是我部署的線上預覽版,大家可以先玩玩看:https://planner.leoshiang.tw/index.html

一、我是如何選擇技術並搭建專案的?

一個清晰的架構是專案成功的第一步。我選用了 Vue 3 生態中我最熟悉且推薦的工具組合,來確保開發效率和未來的可維護性。

1. 核心技術棧

這是我的 package.json 裡的核心依賴,以及我選擇它們的原因:

  • 框架: vue: ^3.5.18 - Vue 3 的組合式 API (Composition API) 是這次開發的核心,它讓我在處理複雜邏輯時能把程式碼組織得更清晰。

  • 狀態管理: pinia: ^3.0.3 - 我選擇了 Vue 官方推薦的 Pinia。它的 API 設計非常直觀,寫起來很舒服,而且對 TypeScript 的支援也很好。

  • 建構工具: vite: ^7.0.6 - 這應該不用多說了,Vite 閃電般的熱更新速度,讓開發體驗非常好。

  • UI/樣式: bootstrap: ^5.3.2 - 為了快速建構出好看的工具列和模態框,我用了 Bootstrap 來處理基本的 UI。

2. 專案結構概覽

在專案結構上,我花了一些心思來劃分不同模組的職責。

/
├── public/
│   └── data/                 # 靜態資料與設定檔 (JSON)
├── src/
│   ├── assets/               # CSS 樣式與資源
│   ├── components/           # 可複用的 Vue 元件
│   │   ├── GanttGrid.vue     # 甘特圖主要網格區域
│   │   ├── GanttHeader.vue   # 甘特圖頂部時間軸
│   │   ├── GanttToolbar.vue  # 功能工具列
│   │   ├── TaskEditPanel.vue # 任務編輯面板
│   │   └── ...
│   ├── composables/          # 我把核心邏輯都抽離在這裡
│   │   ├── useGanttRenderer.js # Canvas 渲染引擎
│   │   ├── useGanttState.js    # 元件 UI 狀態管理
│   │   ├── useInteractionManager.js # 使用者互動 (拖曳、點擊) 管理
│   │   └── gridRenderers/    # 不同視圖模式的網格背景渲染器
│   ├── services/             # 資料來源抽象層
│   │   ├── DataSourceFactory.js
│   │   └── dataSources/
│   │       ├── JsonDataSource.js # JSON 檔案資料來源
│   │       └── ApiDataSource.js  # API 後端資料來源
│   ├── stores/               # Pinia 全域狀態
│   │   └── gantt.js
│   ├── utils/                # 通用工具函數
│   ├── App.vue               # 根元件
│   └── main.js               # 應用程式入口
└── package.json

這個結構有幾個好處:

  • 職責分明composables 集中了核心邏輯,components 專注於 UI,stores 負責全域狀態,各司其職。

  • 易於擴展:我特別設計了 services 層,這樣未來要從讀取本地 JSON 切換到對接後端 API 就會非常容易。同樣地,gridRenderers 的工廠模式也讓新增時間軸視圖(比如「雙週視圖」)變得簡單。

二、核心概念:我是如何用 Canvas 畫出甘特圖的?

甘特圖的本質,就是把時間與任務資料視覺化。為了達成這個目標,我用了一個很關鍵的技巧:多層 Canvas

1. 為何選擇多層 Canvas 渲染?

效能!如果我把網格、任務、互動提示全都畫在同一個 Canvas 上,那滑鼠稍微一動,就可能得重繪所有內容,畫面肯定會卡頓。

所以我採用了經典的多層 Canvas 渲染策略,把不同更新頻率的內容分離到獨立的圖層。你可以在 src/components/GanttGrid.vue 中看到我設計的結構:

HTML

<div ref="timelineGridRef" class="timeline-grid">
    <div :style="{ width: `${totalWidth}px`, height: `${totalHeight}px`, position: 'relative' }">
        <canvas ref="gridCanvas" class="gantt-canvas"></canvas>

        <canvas ref="tasksCanvas" class="gantt-canvas"></canvas>

        <canvas ref="interactionCanvas" class="gantt-canvas"></canvas>
    </div>
</div>

這樣分層後,各層的職責就很明確了:

  • Grid Canvas (底層): 只有在切換視圖或縮放時才需要重繪。

  • Tasks Canvas (中層): 只有在任務資料改變或滾動時才需要重繪。

  • Interaction Canvas (頂層): 當使用者拖曳任務時,我可以在這層高頻率地繪製任務的「預覽影子」,而不用影響底下兩層,確保了拖曳過程的絲滑流暢。

渲染流程

下面的序列圖清晰地展示了使用者操作後,不同 Canvas 圖層是如何協同工作的:

sequenceDiagram
    actor User as 使用者
    participant InteractionCanvas as 互動圖層 (頂層)
    participant useInteractionManager as 互動管理器
    participant useGanttRenderer as 渲染引擎
    participant TasksCanvas as 任務圖層 (中層)
    participant GridCanvas as 網格圖層 (底層)

    User->>InteractionCanvas: 拖曳任務
    InteractionCanvas->>useInteractionManager: 觸發 mousemove 事件
    useInteractionManager->>useInteractionManager: 計算新位置, 更新 dragState
    useInteractionManager->>useGanttRenderer: requestSmoothUpdate(dragState)
    
    Note right of useGanttRenderer: 只重繪任務圖層
    useGanttRenderer->>TasksCanvas: drawTasks(dragState)
    TasksCanvas-->>User: 顯示拖曳預覽 (Ghost Task)

    User->>InteractionCanvas: 釋放滑鼠
    InteractionCanvas->>useInteractionManager: 觸發 mouseup 事件
    useInteractionManager->>Pinia Store: 呼叫 updateTask Action
    
    Note over Pinia Store, User: 狀態變更觸發響應式更新
    Pinia Store-->>useGanttRenderer: 觸發 watchEffect
    useGanttRenderer->>TasksCanvas: drawTasks(null)
    TasksCanvas-->>User: 顯示最終任務位置

2. useGanttRenderer.js:我的渲染引擎

我把所有的繪製邏輯都封裝在了 src/composables/useGanttRenderer.js 這個組合式函數裡。你可以把它想像成是甘特圖的「渲染引擎」,它接收狀態,然後輸出畫面。

JavaScript

// src/composables/useGanttRenderer.js

export function useGanttRenderer(canvases, store, ganttState) {
    // ...
    // 主渲染函數,可以接收拖曳狀態來畫出預覽
    const render = (dragState = null) => {
        // ...
        drawGrid(); 
        drawTasks(dragState); 
    };
    // ...
    return { render };
}

3. 動態網格背景:我如何用工廠模式來簡化

為了處理日、週、月等多種時間視圖,我採用了工廠模式。這是一個絕佳的設計,讓程式碼的擴展性大大增強。

我先定義了一個基礎的 BaseGridRenderer,然後讓 DayGridRendererWeekGridRenderer 等都繼承它,並各自實現自己的繪圖邏輯。最後,我用一個 GridRendererFactory 來根據當前的視圖模式,動態地建立對應的渲染器實例。

這樣一來,如果我未來想新增一個「季度視圖」,只需要新增一個 QuarterGridRenderer.js 並在工廠中註冊它即可,完全不需要修改核心的渲染引擎程式碼。

三、Pinia:專案的靈魂與心臟

如果說 composables 是專案的大腦和四肢,那麼 src/stores/gantt.js 就是專案的靈魂。我用它作為單一事實來源 (Single Source of Truth),集中管理所有狀態。

資料流是怎麼跑的?

這張圖展示了從使用者操作到畫面更新的完整單向資料流,這也是我設計的核心理念。

sequenceDiagram
    actor User as 使用者
    participant Component as Vue 元件 (e.g., GanttToolbar)
    participant Store as Pinia Store (gantt.js)
    participant Composables as 組合式函數 (e.g., useGanttRenderer)
    participant Canvas

    User->>Component: 點擊「切換視圖」按鈕
    Component->>Store: 呼叫 Action: setViewMode('Week')
    Store->>Store: 變更 State: viewMode.value = 'Week'
    
    Note right of Store: State 改變, Getters 自動重新計算
    Store->>Store: Getter (Computed) `activeTimelineConfig` 更新
    
    Note right of Composables: 透過 watchEffect 監聽 Store 變化
    Composables->>Store: 偵測到 `activeTimelineConfig` 變更
    Composables->>Canvas: 呼叫 render() 重新繪製
    Canvas-->>User: 顯示更新後的週視圖

1. State:應用的核心資料

我把狀態分為幾類:projectData (任務資料)、calendarData (假日資訊)、settings (使用者設定) 和 viewMode (UI 狀態) 等。

2. Getters (Computed):衍生的計算狀態

Getters 在這裡扮演了重要角色。其中最關鍵的是 activeTimelineConfig,它會根據 viewModezoomLevel 動態計算出每一天在畫布上應該佔據的寬度 (dayWidth)。

JavaScript

// src/stores/gantt.js (Computed 部分)

const activeTimelineConfig = computed(() => {
    const baseConfig = baseTimelineConfigs[viewMode.value];
    // 將基礎寬度乘以縮放比例
    const adjustedDayWidth = Math.round(baseConfig.dayWidth * zoomLevel.value);
    // ...
});

這個計算屬性是整個響應式渲染系統的核心。當我調整縮放滑桿時,zoomLevel 變化,dayWidth 就會自動更新,最終觸發 Canvas 重繪。

3. Actions:變更狀態的唯一途徑

我堅持所有狀態的變更都必須透過 Actions 來完成。

例如,updateTask 這個 Action 就封裝了相當複雜的業務邏輯。當使用者拖曳一個任務到新的分類時,它不僅要更新任務本身的資料,還要處理跨分類移動、尋找可用行、甚至新增行等操作。把這些邏輯封裝在 Store 裡,讓我的 Vue 元件可以保持非常簡潔。

四、互動的藝術:useInteractionManager.js

處理滑鼠互動是這個專案最有趣也最具挑戰性的部分。我把所有互動邏輯都集中在了 src/composables/useInteractionManager.js

互動狀態機

它的核心是一個小型的狀態機,用來追蹤使用者的拖曳狀態。

stateDiagram-v2
    [*] --> Idle: 初始化
    Idle --> Dragging: mousedown
    Dragging --> Idle: mouseup / dragend

    state Dragging {
        [*] --> CanvasPanning
        state CanvasPanning
        state TaskMoving
        state TaskResizing
    }

我的事件處理流程

整個互動圍繞著 handleMouseDownhandleMouseMovehandleMouseUp 三個核心事件處理器。

  1. handleMouseDown (滑鼠按下): 我會在這裡判斷使用者點擊的是任務還是空白區域,並初始化拖曳狀態。

  2. handleMouseMove (滑鼠移動): 這是最關鍵的一步。我會根據滑鼠的移動即時計算任務的新位置或新長度,但重點是:我不會馬上更新 Pinia Store。我會呼叫一個 requestSmoothUpdate 函數,它會利用 requestAnimationFrame 來高效率地在 interactionCanvas 上繪製一個半透明的「預覽」任務。這就是拖曳時那個流暢的「鬼影 (Ghost Task)」。

  3. handleMouseUp (滑鼠釋放): 當使用者放開滑鼠時,拖曳操作結束。這時候,我才會拿著預覽中計算好的最終資料,呼叫 Pinia Store 的 updateTask() action,將變更永久儲存起來。

我發現這種**「預覽 -> 確認」**的模式,提供了一個既流暢又穩定的互動體驗,同時也讓程式碼的結構更加清晰。

五、其他重點功能的實作分享

1. 分類拖曳排序 (GanttGrid.vue)

這個功能我選擇使用 HTML5 原生的 Drag & Drop API 來實現。透過監聽一系列 drag 事件,在 @drop 時呼叫 Pinia 的 Action 來更新 projectData 陣列的順序,就輕鬆搞定了。

2. 資料來源抽象化 (src/services/)

這是我在架構上一個很得意的設計。我定義了一個 AbstractDataSource 介面,然後分別實作了 JsonDataSourceApiDataSource。這樣一來,我的上層應用完全不需要關心資料是從本地檔案來的還是從遠端 API 來的,大大提高了專案的靈活性和可測試性。

3. 圖表匯出功能 (exportUtils.js)

匯出功能我也用了工廠模式。對於 SVG,我透過程式化地生成 SVG 字串來實現;對於 PNG/JPG,我用了一個更巧妙的方法:動態載入 html2canvas 這個庫,對渲染好的甘特圖 DOM 容器進行「截圖」,然後轉換成圖片供使用者下載。

六、總結與心得

開發這個甘特圖專案對我來說是一次非常寶貴的學習經歷。它讓我深刻體會到:

  1. 組合式 API 的強大:它幫助我將複雜的 UI 邏輯拆分到可複用、可獨立測試的 composables 中。

  2. Pinia 的優雅:透過中心化的 Store,我實現了清晰的單向資料流,讓狀態管理不再混亂。

  3. Canvas 的潛力:利用多層 Canvas 和高效的繪製策略,完全可以建構出高效能、高互動性的複雜圖表。

  4. 軟體設計模式的重要性:像工廠模式和抽象層的運用,真的能大大提高專案的可擴展性和可維護性。

希望這次的開發紀錄分享,能對你在開發複雜 Vue 應用時有所啟發。動手實踐是最好的學習方式,推薦大家可以下載我的原始碼,試著為它添加一個新功能(比如「任務相依性連線」),來鞏固今天所學到的知識吧!